iT邦幫忙

8

📨 用 n8n 自動整理信用卡帳單:從 Gmail 到 LINE 的實戰筆記

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20251107/20155103qWrdMJvsk0.png

🌱 起心動念:為什麼想自動化信用卡帳單?

每個月收到「銀行信用卡」帳單時,我常常只是快速掃過繳款金額,真正的消費細節反而沒有好好看。
帳單是 PDF,又有密碼保護,要打開、搜尋、對照、算總額,整個流程其實不輕鬆。

後來開始用 n8n 做工作上的自動化,就動念把這件事也自動化:

Gmail 收到信用卡帳單 →
自動解出 PDF 文字 →
把「本月消費明細」抽出來 →
丟給 GPT 做結構化整理 →
透過 LINE 推送一份可閱讀的摘要。

https://ithelp.ithome.com.tw/upload/images/20251107/20155103SooyQNFqio.png

這篇是把這段過程中和 ChatGPT 來回討論、踩坑、修正的經驗整理成一篇實作心得。


🧭 流程總覽:從信箱到通知的資料旅程

整條流程拆開來大概是這樣:
範例:國泰世華

1️⃣ Gmail Trigger

  • 只處理主旨以「國泰世華銀行信用卡」開頭的信件
  • 勾選下載附件

2️⃣ PDF 文字抽取

  • 一開始研究 qpdf + pdftotext
  • 後來發現 n8n 裡的 Extract from File 就能直接處理有密碼的 PDF

3️⃣ Function 節點

  • 從整份文字裡,只切出「您本月消費明細如下:~-----------------END-----------------」這一小段
  • 留下原始 raw_segment 給後續 AI 使用

4️⃣ AI Agent(GPT)

  • 輸入整段明細文字
  • 讓模型:
    • 按店家合併金額
    • 找出單筆超過 1000 的交易
    • 計算總額
  • 回傳一個乾淨的 JSON

5️⃣ Function:整理成 LINE 訊息

  • 把 JSON 轉成幾則簡短的文字訊息
  • 例如:摘要、Top 店家、大額交易列表

6️⃣ HTTP Request → LINE Push API

  • 設定好 to(userId)和 messages
  • 寄出推播

這條線中間大概經歷了幾個明顯的轉折:
PDF 解密的做法、正規表示式沒抓到內容、JSON 要怎麼轉成 LINE 格式,
以及 n8n 送 HTTP 的一些小陷阱。
📎 處理加密 PDF:從 qpdf 繞了一圈


一開始的想法很工程師:
既然 PDF 有密碼,就先在主機裝工具,把它解密再處理。

我原本設計的是:

  1. 在 n8n 主機上安裝 qpdf、poppler-utils (pdftotext)

  2. 流程如下:

    • Gmail Trigger 下載附件(binary)
    • Function 節點把 binary re-map 成 inputPdf
    • Write Binary File → /tmp/input.pdf
    • Execute Command:
      qpdf --password=1234 --decrypt /tmp/input.pdf /tmp/decrypted.pdf
      
    • 再用
      pdftotext /tmp/decrypted.pdf -
      
      把文字吐到 stdout

這條路的好處是:

  • 對各種 PDF 都通用,不侷限在某個平台
  • 想加上 OCR(例如 tesseract)時比較彈性

但實作到一半,開始意識到幾個問題:

  • 需要動到主機或 Docker image 安裝工具
  • 路徑、權限、容器掛載點都要自己顧
  • 對於只是個人帳單來說,這條路顯得有點重

📄 發現 Extract from File:回頭用內建節點就好

實際把 n8n 的 Extract from File 節點打開看了一下之後,才發現它已經支援:

  • 直接讀 binary property
  • 模式選「PDF (Text)」
  • 甚至還有 Password 欄位可以填

我那時候遇到的錯誤是:

No password given

意思很單純:PDF 是加密的,但節點沒拿到密碼。
後來調整為:

  • Operation:Extract → PDF (Text)
  • Binary Property:指向 Gmail 節點輸出的附件 key(例如 attachment_0 或 inputPdf)
  • Password:先直接填 1234 測試(之後再改成環境變數)

這樣就直接拿到:

{
  "text": "整份帳單解出來的文字……"
}

比起前面那串 qpdf 指令,這個方案簡單很多,也不需要動 server。


📊 小小比較表:兩種 PDF 解析方式

面向 qpdf + pdftotext Extract from File 節點
需要安裝系統工具 需要 不需要
密碼處理 command line 引數或 env 節點內直接填 Password 欄位
對掃描型 PDF 的支援 要再接 OCR(tesseract 等) 一樣要額外處理
適合大量、客製化流程 是 還可以,但彈性略低
適合個人帳單小專案 顯得有點重 比較輕量、易維護

實務上,我最後採用的是「先用 Extract from File 拿文字」,真的遇到掃描型帳單再考慮加 OCR。


🔍 用 Function 節點切出「本月消費明細」

Extract from File 把整份帳單都轉成文字,但我只在乎中間那一段:

您本月消費明細如下:
…
-----------------END-----------------

原本的正則沒有寫對,抓不到內容。
後來改成這種寫法(支援全形/半形冒號、跨行):

// 1) 取得文字
const text = ($input.first().json.text || "").toString();

// 2) 正則:抓取起迄(冒號可能是全形「:」或半形「:」)
// [\s\S]*? = 非貪婪跨行
const re = /您本月消費明細如下[::]\s*([\s\S]*?)\n\s*-{5,}\s*END\s*-{5,}/;
const m = text.match(re);

if (!m) {
  return [{
    json: {
      ok: false,
      reason: "未找到起迄標記",
      preview_head: text.slice(0, 200),
      preview_tail: text.slice(-200),
    }
  }];
}

// 4) 原始擷取片段
let segment = m[1];

// 5) 清理:移除「-----續下頁-----」、頁碼 1/4 之類
const lines = segment
  .split(/\r?\n/)
  .map(s => s.trimEnd())
  .filter(s => {
    if (/^-{2,}\s*續下頁\s*-{2,}$/u.test(s)) return false;
    if (/^\d+\s*\/\s*\d+$/.test(s)) return false; // 1/4, 2/4...
    return true;
  });

const cleaned = lines
  .map(s => s.replace(/[ \t]{3,}/g, ' '))
  .join('\n')
  .trim();

return [{
  json: {
    ok: true,
    raw_segment: segment.trim(),
    clean_segment: cleaned,
    original_length: text.length,
  }
}];

在這個節點我刻意同時保留:

  • raw_segment:給 AI 處理用
  • clean_segment:如果要直接寫 Google Sheet,可以用這段

這一步解決之後,整份明細就變成一塊「乾淨文字」,可以交給 GPT 解析。


🤖 與 GPT 設計「帳單解析 Agent」

接下來就是這次專案裡最 AI 的部分:
把 raw_segment 丟給 GPT,希望它幫我做三件事:

  1. 將同一家店的消費合併
  2. 列出單筆金額 ≥ 1000 的交易
  3. 算出所有交易總額

我寫了一個偏完整的 Prompt,交代:

  • 帳單裡會有雜訊行(表頭、頁碼、續下頁)要排除
  • 「樂購蝦皮-某某」這種字串要抽出店家名稱
  • 負號代表退款或點數折抵,計算總額時照實相加
  • 請回傳一個固定格式的 JSON,包含:
    • summary
    • by_merchant
    • large_transactions
    • skipped

實際跑完之後,得到一份類似這樣的結構(節錄):

{
  "summary": {
    "currency": "TWD",
    "total_amount": 10574,
    "transaction_count": 86
  },
  "by_merchant": [
    {
      "merchant": "CURSOR, AI POWERED IDE",
      "normalized_merchant": "CURSOR,AI POWERED IDE",
      "count": 1,
      "amount": 5879,
      "first_date": "08/27",
      "last_date": "08/27",
      "items": [ ... ]
    }
  ],
  "large_transactions": [
    {
      "date": "08/27",
      "merchant": "CURSOR, AI POWERED IDE",
      "amount": 5879
    },
    {
      "date": "10/05",
      "merchant": "連加*FOCASA馬戲",
      "amount": 1200
    }
  ],
  "skipped": [
    {
      "line": "新臺幣",
      "reason": "Excluded by keyword/pattern"
    }
  ]
}

這個 JSON 變成後面所有步驟的基礎。

✉️ 用 Function 節點把 JSON 轉成 LINE 訊息

有了 JSON 之後,我想在 LINE 上看到的不是原始資料,而是:

  • 一則「總覽」
  • 一則「大額交易列表」
  • 一則「Top 店家」

所以又加了一個 Function 節點,專門整理成 LINE 所需的 messages 陣列:

const data = $input.first().json;

if (!data || !data.summary || !Array.isArray(data.by_merchant)) {
  return [{
    json: {
      messages: [{ type: 'text', text: '解析失敗:輸入資料缺少 summary 或 by_merchant。' }]
    }
  }];
}

const fmt = (n) => {
  const s = (n || 0).toString();
  return s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};

// 1) 摘要
const summaryLines = [];
summaryLines.push('📊 信用卡摘要');
summaryLines.push(`• 幣別:${data.summary.currency}`);
summaryLines.push(`• 總額:$${fmt(data.summary.total_amount)}`);
summaryLines.push(`• 筆數:${data.summary.transaction_count}`);
const msgSummary = summaryLines.join('\n');

// 2) 大額交易
const bigs = Array.isArray(data.large_transactions) ? data.large_transactions : [];
let msgBigs;
if (bigs.length === 0) {
  msgBigs = '💥 大額交易 (≥ 1,000)\n(無)';
} else {
  const topBigs = bigs.slice().sort((a, b) => b.amount - a.amount).slice(0, 10);
  const lines = topBigs.map(t => `• ${t.date} ${t.merchant} $${fmt(t.amount)}`);
  msgBigs = '💥 大額交易 (≥ 1,000)\n' + lines.join('\n');
}

// 3) Top 店家
const merchants = data.by_merchant.slice().sort((a, b) => (b.amount || 0) - (a.amount || 0));
const topCount = Math.min(5, merchants.length);
const topLines = [];
for (let i = 0; i < topCount; i++) {
  const m = merchants[i];
  const name = m.normalized_merchant || m.merchant || '(未命名)';
  topLines.push(`${i + 1}. ${name} $${fmt(m.amount || 0)}(${m.count || 0}筆)`);
}
const msgTop = '🏪 Top 店家(合併後)\n' + (topLines.join('\n') || '(無)');

// 4) chunk 保護
const MAX_LEN = 4500;
const chunkText = (text) => {
  if (text.length <= MAX_LEN) return [text];
  const chunks = [];
  let start = 0;
  while (start < text.length) {
    chunks.push(text.slice(start, start + MAX_LEN));
    start += MAX_LEN;
  }
  return chunks;
};

const messages = []
  .concat(chunkText(msgSummary).map(t => ({ type: 'text', text: t })))
  .concat(chunkText(msgBigs).map(t => ({ type: 'text', text: t })))
  .concat(chunkText(msgTop).map(t => ({ type: 'text', text: t })));

return [{ json: { messages } }];

這樣下一個節點只要把 $json.messages 塞進 HTTP Request 的 body 就好。

📲 HTTP Request + LINE Push API:解決 [object Object] 踩雷

一開始我在 HTTP Request 的 result 裡看到:

{ "to": "Uxxxx", "messages": [object Object],[object Object],[object Object] }

這代表我在 n8n 裡把 messages 當成「字串」送出,陣列被隱形轉成了 [object Object]。


✅ 解法是兩個關鍵點:

1️⃣ Body 要用真正的 JSON,而不是字串

在 HTTP Request 節點中:

  • Method:POST

  • URL:https://api.line.me/v2/bot/message/push

  • Send:選 JSON

  • Headers:

    Authorization: Bearer {{$env.LINE_CHANNEL_TOKEN}}
    Content-Type: application/json
    
  • Body 設定:

    {
      "to": "U861cc0a2877fb37e1f8b4f55ee8d54a0",
      "messages": {{$json.messages}}
    }
    

⚠️ 注意 {{$json.messages}} 外面 不要再包引號。


2️⃣ 或者改用 Raw + JSON.stringify

另一種做法是開啟 JSON/RAW:

{{ JSON.stringify({
  to: "U861...",
  messages: $json.messages
}) }}

這樣可以確定送出去的是合法 JSON 字串。


🎉 結果

調整完之後,LINE 就順利收到三則訊息:

  1. 信用卡摘要
  2. 大額交易列表
  3. Top 店家合併資訊

對個人理財來說,這個資訊密度剛好,不會被塞滿一堆細碎的品項。

🔧 實作過程中的幾個小重點

回頭看整個流程,大概有幾個心得:

  1. 先用內建節點
    一開始直接往 qpdf 等系統工具衝,但對這種規模的需求,其實 Extract from File 就很夠用了。

  2. 正則寫法要考慮真實文本的雜訊
    像全形冒號、換頁標示、頁碼行都是很真實的坑,光看 PDF 視覺畫面其實不容易注意。

  3. AI 的角色是「解析 + 聚合」,不是單純問答
    我給 GPT 的任務是把原始文字變成結構化 JSON,之後所有動作(算總額、排行)都在 n8n 裡完成,這樣比較穩定。

  4. n8n 的表達式和 JSON 型別要小心
    [object Object] 幾乎就是「物件被當字串」的代名詞。
    只要看到這個,多半是少了 JSON.stringify 或是外面多包了一層引號。


🔮 後續可以加的東西

目前這條線已經可以自動送出摘要,不過還有一些想做但還沒做的:

  • 加上 IF 條件:例如只在 total_amount 超過某個門檻時才推播
  • 接一個 Google Sheets Append 節點,把 by_merchant 或 raw items 長期累計
  • 依照月份加上簡單的趨勢比較,例如本月 vs 上月總額差異
  • 把通知改成 LINE Flex Message,做成比較圖或卡片式排版

這些留給之後有空再慢慢疊上去。


💬 給 GPT 用的 Prompt 範例

下面整理幾個在這個專案中實際用到、或現在回頭看覺得實用的 Prompt,可以直接複製調整:

1️⃣ 把帳單明細文字解析成 JSON

你是一個嚴謹的帳單文字解析器。
下面是國泰世華信用卡「本期消費明細文字」,請依規則解析並只輸出 JSON,不要額外說明。

  • 忽略表頭、頁碼(例如 1/4)、「-----續下頁-----」等非交易行
  • 每行若包含日期、交易說明、新臺幣金額,就視為一筆交易
  • 新臺幣金額為整數,負號代表退款或點數折抵
  • 合併同一店家的金額,並統計筆數與最早/最晚日期
  • 列出所有單筆金額 ≥ 1000 的正向交易
  • 計算所有交易的總額(包含負數)

請以以下 JSON 結構輸出:
{ "summary": {...}, "by_merchant": [...], "large_transactions": [...], "skipped": [...] }
其中 summary.total_amount 為整數(TWD),large_transactions 僅包含金額 ≥ 1000 的交易。


2️⃣ 幫忙寫 n8n Function 節點的 RegExp

我有一段帳單文字,存在 $input.first().json.text。
請幫我寫一段 n8n Function 節點用的 JavaScript,從這段文字中取出
「您本月消費明細如下:」到「-----------------END-----------------」之間的所有內容:

  • 支援全形或半形冒號
  • 可以跨多行
  • 回傳 { json: { ok: true, raw_segment, clean_segment } }
    其中 clean_segment 請幫我移除「-----續下頁-----」和純頁碼(例如 1/4、2/4)。

3️⃣ 幫忙把 JSON 摘要轉成 LINE 訊息文字

我會提供一個 JSON,格式包含 summary、by_merchant、large_transactions。
請幫我用這些資料組成三段適合 LINE 推播的文字訊息:

  • 信用卡摘要(幣別、總額、筆數)
  • 大額交易列表(只顯示金額 ≥ 1000 的交易)
  • 依合併金額排序的 Top 5 店家(顯示店名、總金額、筆數)

請輸出一個陣列,每個元素是 { "type": "text", "text": "..." } 的物件。


4️⃣ 請 GPT 幫忙檢查 n8n HTTP Request 設定

下面是我在 n8n HTTP Request 節點打 LINE Push API 的 body 與 headers。
請幫我檢查:

  • 是否為合法的 JSON
  • messages 欄位是否為陣列,而不是字串
  • 有沒有可能造成 [object Object] 被送出去

並給我一個正確示範。


5️⃣ 將 workflow 寫成文件說明

我有一條 n8n workflow:
Gmail → Extract from File → Function → AI → Function → HTTP Request。

請幫我整理成一篇教學文件的大綱,用條列方式描述每一個節點的目的、關鍵設定、可能踩到的坑。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
DanSnow
iT邦好手 1 級 ‧ 2025-11-09 19:53:32

好奇你有去驗算一下數字的部份嗎?因為我看你在計算店家的交易的總額的部份是找 LLM 處理的,而 LLM 的數學不一定可信

沒有驗算,我知道LLM數學不穩定
因為我沒有期望獲得完全精準的數字
只想被動的可以對整月消費內容有個印象

DanSnow iT邦好手 1 級 ‧ 2025-11-09 19:59:18 檢舉

了解,可以理解,謝謝

我要留言

立即登入留言